Skip to content

Add solana-pay-request: build a Solana Pay transfer-request URL, no permissions - #156

Open
belumume wants to merge 2 commits into
zeroclaw-labs:mainfrom
belumume:add-solana-pay-request
Open

Add solana-pay-request: build a Solana Pay transfer-request URL, no permissions#156
belumume wants to merge 2 commits into
zeroclaw-labs:mainfrom
belumume:add-solana-pay-request

Conversation

@belumume

@belumume belumume commented Aug 28, 2026

Copy link
Copy Markdown

Adds solana-pay-request, a tool plugin that turns a payment intent into a Solana Pay transfer-request URL and a QR-ready payload a customer's wallet scans to pay.

Given a recipient and an optional amount, SPL mint, reference keys, label, message and memo, it validates every field and builds a solana: URL faithful to the Solana Pay spec. The agent-facing shape is one tool call: "charge table 4 for 25 USDC" comes back as a scannable URL.

solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=25&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&memo=table%204

Custody: permissions = []

It holds no key, reads no config and opens no socket. That is not a claim about the source, it is a property of the component: a WASI interface can only be imported by name, so the name is in the binary if the capability is reachable. On this build:

wasi:http 0        wasi:cli 23
wasi:sockets 0     wasi:io 11
wasi:filesystem 0  zeroclaw:plugin 15

The right-hand column is the control. Three zeros mean nothing unless the same command returns non-zero for interfaces that are genuinely present, and the README ships both loops so a reviewer can re-run them against the artifact they built rather than take the numbers from me.

Nothing it emits can move money on its own. The output is a payment request; the payer's wallet builds and signs the transaction.

Free text cannot restructure the URL

recipient, amount, spl-token and reference each arrive in their own typed field and are validated before the URL is built. label, message and memo are stripped of control, bidi and zero-width characters, then percent-encoded at build time. A memo reading table 4&recipient=<attacker>&amount=999 becomes one encoded memo value rather than a second recipient, which is pinned as an integration test.

Vendoring

src/solana_core/ is copied from the plugin's source repository, with the repository, revision and file paths named in its module header.

Vendoring is deliberate rather than convenient. A plugin here is built from its own directory plus wit/, so a path dependency reaching outside that directory cannot resolve, and the crate it would name is unpublished. sanitize.rs is verbatim. pubkey.rs is cut down to the base58 codec, which drops the curve25519-dalek, ed25519-dalek and sha2 dependencies that only the omitted PDA and on-curve code needed; the reduction is recorded in the file so a future need for derivation pulls the upstream module back whole instead of regrowing it by hand. bs58 is the one dependency added beyond the template's.

What I ran

Against this branch, from plugins/solana-pay-request/:

command result
cargo test --locked 84 passed, 0 failed (80 unit, 4 integration)
cargo clippy --locked --all-targets -- -D warnings clean
cargo clippy --locked --target wasm32-wasip2 -- -D warnings clean
cargo build --locked --target wasm32-wasip2 --release 202,662 byte component
cargo fmt --all -- --check clean
git diff --check origin/main HEAD clean
tools/build-registry.py --source-plugins plugins --check-metadata registry.json matches, pending unpublished source: solana-pay-request@0.1.0

tools/ci/validate_components.sh solana-pay-request was also run directly, so the four cargo commands above were exercised through the snapshot the workflow actually builds in rather than in a working tree that happened to have more in it.

Two caveats, stated rather than left for CI to surface. The tools/tests and tools/ci/tests suites report failures on my machine, but they fail identically on an unmodified checkout of main and every failing case is a symlink or non-regular-file test, which is a Windows limitation rather than anything this branch touches. And registry.json is untouched, since it is generated.

Fit

The registry currently indexes one tool plugin against thirty channel plugins. This is a tool plugin with no permissions at all, which makes it about the cheapest possible thing to review and install, and it is useful to any agent on any of those channels: taking a payment is not chain-specific plumbing, it is the thing the operator wanted the agent for.

Happy to split the vendored module out, rename, or adjust the manifest if any of it cuts against how you would rather see third-party plugins land here.

Validation

CI does not run on pull requests from forks in this repo, so here is the output of your own
validator, unmodified, on this branch:

test_rc=0  tests_passed=84  clippy_rc=0  wasm_clippy_rc=0
build_rc=0  artifact_bytes=202662  strict=true  registry=true

Reproduce with:

bash tools/ci/validate_components.sh --plugin solana-pay-request --strict --registry

Also clean: cargo fmt --check, git diff --check against main, and --check-metadata, which
reports pending unpublished source: solana-pay-request@0.1.0.

The manifest declares permissions = []. That is verified against the built artifact rather than
the source: wasi:http, wasi:sockets and wasi:filesystem each appear 0 times in the component,
with wasi:cli at 23, wasi:io at 11 and zeroclaw:plugin at 15 confirming the check reads the
binary.

Two limits worth stating rather than leaving you to find. It was built on Rust 1.97.1, not the
1.96.1 your workflow pins, so that combination is untested by me. And it has not been built on
Linux; your tools/tests and tools/ci/tests suites fail on my machine, but they fail
byte-identically on an unmodified checkout of main and every failing case is a symlink test, so
that looks pre-existing and platform-specific rather than related to this change.

Turns a payment intent into a Solana Pay transfer-request URL and a QR-ready
payload a customer's wallet scans to pay. Given a recipient and an optional
amount, SPL mint, reference keys, label, message and memo, it validates every
field and builds a `solana:` URL faithful to the Solana Pay spec.

The plugin declares `permissions = []`. It holds no key, reads no config and
opens no socket, so a compromised copy has no I/O surface at all. The built
component imports no `wasi:http`, no `wasi:sockets` and no `wasi:filesystem`,
and the README gives a grep over the artifact, with a control, that lets a
reader confirm that rather than take it on trust.

Free text is the interesting part. `recipient`, `amount`, `spl-token` and
`reference` each arrive in their own typed field and are validated before the
URL is built, while `label`, `message` and `memo` are stripped of control,
bidi and zero-width characters and then percent-encoded, so a memo reading
`table 4&recipient=<attacker>&amount=999` becomes one encoded memo value
instead of a second recipient.

`src/solana_core/` is vendored from the plugin's source repository, named and
dated in its own module header. A registry plugin builds from its directory
alone, so a path dependency reaching outside it cannot resolve. `sanitize.rs`
is verbatim; `pubkey.rs` is cut down to the base58 codec, which drops the
curve25519, ed25519 and sha2 dependencies the omitted PDA derivation needed.

84 host tests pass with no wasm toolchain and no network: 80 unit tests over
the request core and the vendored modules, plus 4 integration tests covering
the URL the README documents, the injection case above, a rejected recipient,
and the promise that `qr_payload` is byte-identical to `url`.
The header claimed sanitize.rs was verbatim. It differs from upstream by
one module-doc paragraph, rewritten here because the original made a
comparative claim about other plugins that a reader of this repo has no way
to check.

The logic is unchanged. Only the claim about the copy was wrong, and a
reader who trusts it does not diff.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant